Trouble Shooting

[Next.js] 블로그의 SEO : ISR + next-sitemap

[Next.js] 블로그의 SEO : ISR + next-sitemap

기술 스택 및 로직 결정

포트폴리오 겸 블로그 개발에 Next.js를 사용했다. 이 과정에서 노션에 작성된 포트폴리오와 블로그 데이터를 일종의 CMS처럼 사용했는데, 포트폴리오 메인 페이지에 렌더링이 필요한 데이터는 노션에서 서버리스 함수와 getStaticProps를 사용해 로드했고, ISR을 사용하여 60초마다 데이터를 재생성하도록 설정했다. 프로젝트와 블로그의 본문 데이터는 /posts/[category]/[tag]/[slug]/[id]로 이동 시 확인할 수 있도록 설계했다.
 
export async function getStaticProps() { const databaseList = ['works', 'posts']; try { const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const props = { works: data[0], posts: data[1], }; return { props, revalidate: 60 }; } catch (e) { console.log('getStaticProps error >>', e); return { props: { works: [], posts: [], }, revalidate: 60 }; } }

SEO 최적화

이 과정에서 SEO를 위해 두 가지 로직을 추가로 적용했다.
  1. getStaticPath를 사용해 [category], [tag], [slug], [id]에 해당하는 데이터를 노션에서 미리 로드하고, getStaticProps로 전달하여 [id]에 해당하는 값을 사용해 프로젝트와 블로그의 본문 데이터를 미리 로드했다.
export async function getStaticPaths() { const databaseList = ['works', 'posts']; const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const paths = data.reduce((acc, cur) => { return [ ...acc, ...cur.results.map((post) => { return { params: { category: post.properties?.category?.select.name, tag: post.properties?.tag?.multi_select[0].name, slug: post.properties?.slug?.rich_text[0]?.plain_text, id: post.id, }, }; }), ]; }, []); return { paths, fallback: 'blocking', }; } export async function getStaticProps({ params }) { const { id } = params; const notion = new NotionAPI({ activeUser: process.env.NOTION_ACTIVE_USER, authToken: process.env.NOTION_TOKEN_V2, }); const recordMap = await notion.getPage(id); return { props: { recordMap, }, revalidate: 60, }; }
  1. postbuild 시 /[category]/[tag]/[slug]/[id]의 값을 모두 불러오는 스크립트를 생성했다. 그리고 next-sitemap을 사용하여 게시된 모든 프로젝트와 블로그의 라우터에 해당하는 정보를 포함한 sitemap을 생성하여 구글 서치콘솔과 연동했다.
/** @type {import('next-sitemap').IConfig} */ const fs = require('fs'); module.exports = { siteUrl: 'https://hyeonjong.com', generateRobotsTxt: true, sitemapSize: 7000, changefreq: 'daily', priority: 1, additionalPaths: async (config) => { const dynamicRoutes = JSON.parse(fs.readFileSync('dynamicRoutesForSitemap.json', 'utf8')); return dynamicRoutes.map(({ category, tag, slug, id }) => { return { loc: `/posts/${category}/${tag}/${slug}/${id}` }; }); }, robotsTxtOptions: { policies: [ { userAgent: '*', allow: '/', disallow: [], }, ], }, };
// scripts/generateDynamicRoutesForSitemap.js const fs = require('fs'); const fetch = require('node-fetch'); const getDynamicPaths = async () => { const databaseList = ['works', 'posts']; const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const paths = data.reduce((acc, cur) => { return [ ...acc, ...cur.results.map((post) => { return { category: post.properties?.category?.select.name, tag: post.properties?.tag?.multi_select[0].name, slug: post.properties?.slug?.rich_text[0]?.plain_text, id: post.id, }; }), ]; }, []); // 경로 정보를 파일에 저장 fs.writeFileSync('dynamicRoutesForSitemap.json', JSON.stringify(paths), 'utf8'); }; getDynamicPaths();